Skip to content

Faster HasOffsetType, HasOffsetValueType intersection - #6195

Merged
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-gwwhj8w
Aug 8, 2026
Merged

Faster HasOffsetType, HasOffsetValueType intersection#6195
staabm merged 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-gwwhj8w

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

isset() with many array offsets made analysis time double with every extra offset — the reported snippet (26 optional keys of a @phpstan-type array shape checked in one isset()) never finished, while 13 offsets already took a minute.

Root cause

Every AccessoryType must be given a base type, which TypeCombinator::doIntersect() supplies with

return self::intersect(self::intersect(...$accessoryBaseTypes), ...$types);

where $accessoryBaseTypes holds one getDefaultBaseType() per accessory type. Each isset() subject narrows the array with a HasOffsetType, and HasOffsetType::getDefaultBaseType() returns the union array|ArrayAccess. So n offsets built a list of n identical two-member unions, and the A & (B | C) distribution multiplies operands out one union at a time — 2^n recursive intersect() calls before the duplicates were finally recognized at the leaves by the pairwise isSuperTypeOf() pass.

Change

src/Type/TypeCombinator.php — while collecting $accessoryBaseTypes, skip a base type that an earlier accessory already contributed (equals()). This is sound because intersection is idempotent, and it fixes the problem where the duplicate operands are built rather than cleaning them up afterwards, so the rest of doIntersect() is untouched.

The two accessory types that return a union from getDefaultBaseType()HasOffsetType and HasOffsetValueType, both array|ArrayAccess — are the ones that could blow up. The rest return string, array or ObjectWithoutClassType; their base-type intersection was already polynomial, and deduplicating them only shortens the operand list. On the PHP level the affected constructs are isset() chains and !empty() chains (empty() narrows through !isset()); array_key_exists() chains, isset($obj->x, $obj->y, …) (HasPropertyType) and ($a['x'] ?? '') !== '' chains were already linear and are unchanged.

The change is purely about how the answer is computed — the resulting types are identical before and after.

Measurements

Reproducer at -l 8, 26 offsets, cold cache:

wall clock
before > 300 s (timed out)
after 3.3 s

@staabm's full benchmark run shows no movement on any other data file, with the new bug-15061.php at ~261 ms.

Test

tests/bench/data/bug-15061.php — the reproducer from the issue, added to the benchmark suite the way other performance regressions in this repo are pinned. Per review feedback there is no unit-level timing test; the benchmark data file covers the same blowup end to end without a flaky wall-clock budget.

make tests OK (21235 tests, 96758 assertions, 97 skipped), make phpstan no errors.

Fixes phpstan/phpstan#15061

@staabm
staabm force-pushed the create-pull-request/patch-gwwhj8w branch from 3b94b04 to 696a0d2 Compare August 8, 2026 07:34
Comment thread tests/PHPStan/Type/TypeCombinatorTest.php
@staabm

staabm commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

ran the benchmarks locally and it looks really good.

no side effects on the other benchmarks, and the newly added one is now wicked fast:

PHPBench (1.7.0) running benchmarks... #standwithukraine
with configuration file: /Users/staabm/workspace/phpstan-src/phpbench.json
with PHP version 8.5.9, xdebug ❌, opcache ❌
comparing [actual vs. ]

\PHPStan\Benchmark\RegressionBench

    benchRunAnalyse # and-chain-truthy-blow.I4 ✔ [Mo78.536ms vs. Mo76.539ms] +2.61% (±0.88%)
    benchRunAnalyse # big-constant-int-unio.I4 ✔ [Mo285.764ms vs. Mo284.586ms] +0.41% (±0.34%)
    benchRunAnalyse # big-constant-string-u.I4 ✔ [Mo129.901ms vs. Mo128.988ms] +0.71% (±0.23%)
    benchRunAnalyse # bug-1388.php..........I4 ✔ [Mo35.948ms vs. Mo35.564ms] +1.08% (±0.97%)
    benchRunAnalyse # bug-1447.php..........I4 ✔ [Mo8.212ms vs. Mo8.133ms] +0.98% (±2.27%)
    benchRunAnalyse # bug-3686.php..........I4 ✔ [Mo4.437ms vs. Mo4.370ms] +1.52% (±1.79%)
    benchRunAnalyse # bug-4300.php..........I4 ✔ [Mo2.275ms vs. Mo2.223ms] +2.34% (±1.93%)
    benchRunAnalyse # bug-4308.php..........I4 ✔ [Mo1.285ms vs. Mo1.266ms] +1.52% (±2.21%)
    benchRunAnalyse # bug-5081.php..........I4 ✔ [Mo302.707ms vs. Mo302.648ms] +0.02% (±0.16%)
    benchRunAnalyse # bug-5231.php..........I4 ✔ [Mo6.951ms vs. Mo6.925ms] +0.37% (±0.48%)
    benchRunAnalyse # bug-5231_2.php........I4 ✔ [Mo6.607ms vs. Mo6.538ms] +1.04% (±2.00%)
    benchRunAnalyse # bug-5390.php..........I4 ✔ [Mo612.849μs vs. Mo610.409μs] +0.40% (±3.90%)
    benchRunAnalyse # bug-6265.php..........I4 ✔ [Mo55.669ms vs. Mo54.845ms] +1.50% (±0.54%)
    benchRunAnalyse # bug-6442.php..........I4 ✔ [Mo1.364ms vs. Mo1.347ms] +1.28% (±2.54%)
    benchRunAnalyse # bug-6936.php..........I4 ✔ [Mo28.998ms vs. Mo28.946ms] +0.18% (±0.52%)
    benchRunAnalyse # bug-6948.php..........I4 ✔ [Mo17.692ms vs. Mo17.724ms] -0.18% (±1.35%)
    benchRunAnalyse # bug-7140.php..........I4 ✔ [Mo10.392ms vs. Mo10.340ms] +0.50% (±0.73%)
    benchRunAnalyse # bug-7214.php..........I4 ✔ [Mo1.200ms vs. Mo1.195ms] +0.36% (±4.14%)
    benchRunAnalyse # bug-7581.php..........I4 ✔ [Mo167.316ms vs. Mo165.762ms] +0.94% (±0.15%)
    benchRunAnalyse # bug-7637.php..........I4 ✔ [Mo7.469ms vs. Mo7.429ms] +0.54% (±0.84%)
    benchRunAnalyse # bug-7901.php..........I4 ✔ [Mo316.631ms vs. Mo297.569ms] +6.41% (±2.96%)
    benchRunAnalyse # bug-7903.php..........I4 ✔ [Mo741.200ms vs. Mo738.274ms] +0.40% (±0.19%)
    benchRunAnalyse # bug-8146a.php.........I4 ✔ [Mo56.122ms vs. Mo56.053ms] +0.12% (±0.88%)
    benchRunAnalyse # bug-8146b.php.........I4 ✔ [Mo430.477ms vs. Mo428.794ms] +0.39% (±0.28%)
    benchRunAnalyse # bug-8147.php..........I4 ✔ [Mo126.117ms vs. Mo126.626ms] -0.40% (±0.27%)
    benchRunAnalyse # bug-8215.php..........I4 ✔ [Mo218.835ms vs. Mo218.419ms] +0.19% (±0.95%)
    benchRunAnalyse # bug-8503.php..........I4 ✔ [Mo76.707ms vs. Mo76.954ms] -0.32% (±0.70%)
    benchRunAnalyse # bug-9690.php..........I4 ✔ [Mo70.940ms vs. Mo70.853ms] +0.12% (±1.14%)
    benchRunAnalyse # bug-10147.php.........I4 ✔ [Mo284.926μs vs. Mo288.421μs] -1.21% (±2.19%)
    benchRunAnalyse # bug-10538.php.........I4 ✔ [Mo812.353ms vs. Mo814.226ms] -0.23% (±0.19%)
    benchRunAnalyse # bug-10772.php.........I4 ✔ [Mo131.610ms vs. Mo131.359ms] +0.19% (±0.15%)
    benchRunAnalyse # bug-10979.php.........I4 ✔ [Mo253.079ms vs. Mo254.305ms] -0.48% (±0.23%)
    benchRunAnalyse # bug-11263.php.........I4 ✔ [Mo125.210ms vs. Mo125.306ms] -0.08% (±0.51%)
    benchRunAnalyse # bug-11283.php.........I4 ✔ [Mo364.173ms vs. Mo366.181ms] -0.55% (±0.38%)
    benchRunAnalyse # bug-11297.php.........I4 ✔ [Mo524.256ms vs. Mo523.692ms] +0.11% (±0.25%)
    benchRunAnalyse # bug-11913.php.........I4 ✔ [Mo76.570ms vs. Mo77.694ms] -1.45% (±1.00%)
    benchRunAnalyse # bug-12159.php.........I4 ✔ [Mo63.468ms vs. Mo64.856ms] -2.14% (±0.51%)
    benchRunAnalyse # bug-12671.php.........I4 ✔ [Mo173.394ms vs. Mo175.139ms] -1.00% (±3.49%)
    benchRunAnalyse # bug-12787.php.........I4 ✔ [Mo3.176ms vs. Mo2.947ms] +7.78% (±5.45%)
    benchRunAnalyse # bug-12800.php.........I4 ✔ [Mo561.257ms vs. Mo559.025ms] +0.40% (±0.93%)
    benchRunAnalyse # bug-13218.php.........I4 ✔ [Mo9.604ms vs. Mo9.668ms] -0.66% (±0.83%)
    benchRunAnalyse # bug-13310.php.........I4 ✔ [Mo20.964ms vs. Mo21.464ms] -2.33% (±1.23%)
    benchRunAnalyse # bug-13352.php.........I4 ✔ [Mo1.228s vs. Mo1.227s] +0.13% (±2.13%)
    benchRunAnalyse # bug-13685.php.........I4 ✔ [Mo7.431ms vs. Mo7.430ms] +0.01% (±4.18%)
    benchRunAnalyse # bug-13933.php.........I4 ✔ [Mo103.953ms vs. Mo104.702ms] -0.72% (±1.83%)
    benchRunAnalyse # bug-14207-and.php.....I4 ✔ [Mo120.957ms vs. Mo119.543ms] +1.18% (±0.64%)
    benchRunAnalyse # bug-14207.php.........I4 ✔ [Mo31.626ms vs. Mo31.464ms] +0.51% (±2.05%)
    benchRunAnalyse # bug-14319.php.........I4 ✔ [Mo25.511ms vs. Mo25.110ms] +1.60% (±1.42%)
    benchRunAnalyse # bug-14452.php.........I4 ✔ [Mo11.271ms vs. Mo11.205ms] +0.59% (±1.08%)
    benchRunAnalyse # bug-14462.php.........I4 ✔ [Mo55.002ms vs. Mo54.909ms] +0.17% (±1.84%)
    benchRunAnalyse # bug-14475.php.........I4 ✔ [Mo9.823ms vs. Mo9.789ms] +0.36% (±0.63%)
    benchRunAnalyse # bug-14590.php.........I4 ✔ [Mo111.089ms vs. Mo112.355ms] -1.13% (±0.60%)
    benchRunAnalyse # bug-14624.php.........I4 ✔ [Mo1.128s vs. Mo1.127s] +0.14% (±0.17%)
    benchRunAnalyse # bug-14674.php.........I4 ✔ [Mo717.532ms vs. Mo721.268ms] -0.52% (±0.81%)
    benchRunAnalyse # bug-14869-enum.php....I4 ✔ [Mo88.636ms vs. Mo88.433ms] +0.23% (±0.60%)
    benchRunAnalyse # bug-14869.php.........I4 ✔ [Mo132.674ms vs. Mo132.216ms] +0.35% (±0.22%)
    benchRunAnalyse # bug-14972-concat.php..I4 ✔ [Mo805.988ms vs. Mo805.661ms] +0.04% (±0.56%)
    benchRunAnalyse # bug-14972.php.........I4 ✔ [Mo687.986ms vs. Mo685.307ms] +0.39% (±0.62%)
    benchRunAnalyse # bug-14996.php.........I4 ✔ [Mo657.428ms vs. Mo658.996ms] -0.24% (±0.34%)
    benchRunAnalyse # bug-15061.php.........I4 ✔ Mo261.238ms (±0.31%)
    benchRunAnalyse # conditional-expressio.I4 ✔ [Mo2.731ms vs. Mo2.694ms] +1.36% (±0.43%)
    benchRunAnalyse # finite-types-optional.I4 ✔ [Mo3.051ms vs. Mo3.048ms] +0.09% (±1.40%)
    benchRunAnalyse # flatten-types-optiona.I4 ✔ [Mo918.014μs vs. Mo898.137μs] +2.21% (±2.06%)
    benchRunAnalyse # hash-key-lookup.php...I4 ✔ [Mo226.499ms vs. Mo223.660ms] +1.27% (±0.32%)
    benchRunAnalyse # implode-optional-keys.I4 ✔ [Mo1.131ms vs. Mo1.144ms] -1.08% (±2.91%)
    benchRunAnalyse # impure-call-columns.p.I4 ✔ [Mo452.273ms vs. Mo450.784ms] +0.33% (±0.13%)
    benchRunAnalyse # in-array-intersect-bl.R1 I1 ✔ [Mo11.874ms vs. Mo12.780ms] -7.09% (±1.19%)
    benchRunAnalyse # nullsafe-chain-walk.p.R1 I4 ✔ [Mo753.610ms vs. Mo757.469ms] -0.51% (±0.13%)
    benchRunAnalyse # or-chain-falsey-blowu.R1 I4 ✔ [Mo12.433ms vs. Mo12.379ms] +0.43% (±0.98%)
    benchRunAnalyse # or-chain-resolve-type.R1 I4 ✔ [Mo454.050ms vs. Mo445.381ms] +1.95% (±0.66%)
    benchRunAnalyse # phpdoc-inheritance-sp.R1 I4 ✔ [Mo164.261ms vs. Mo161.799ms] +1.52% (±4.51%)
    benchRunAnalyse # phpdoc-intersection-b.R1 I4 ✔ [Mo2.039ms vs. Mo1.992ms] +2.37% (±1.20%)
    benchRunAnalyse # process-called-method.R1 I4 ✔ [Mo5.648ms vs. Mo5.602ms] +0.81% (±1.09%)
    benchRunAnalyse # union-fast-path.php...R1 I4 ✔ [Mo62.228ms vs. Mo61.206ms] +1.67% (±0.31%)
    benchRunAnalyse # wordpress-user.php....R1 I4 ✔ [Mo297.967ms vs. Mo297.825ms] +0.05% (±0.51%)
    ```

@staabm

staabm commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

//cc @SanderMuller please review

…efore distributing them

* `TypeCombinator::doIntersect()` now calls a new `removeDuplicateUnions()` before the
  `A & (B | C)` distribution, so n copies of the same union no longer get multiplied out
  into 2^n recursive `intersect()` calls. Restricted to the exact `UnionType` class, like
  the finite fast path right above it: `equals()` ignores a `TemplateUnionType`'s variance
  strategy, and `BenevolentUnionType` keeps its dedicated handling.
* The blowup was reached through `TypeCombinator::doIntersect()`'s accessory-base-type
  branch: `HasOffsetType` and `HasOffsetValueType` both return `array|ArrayAccess` from
  `getDefaultBaseType()`, so intersecting n of them intersected n identical two-member
  unions. `isset()` (and `!empty()`, which narrows through `!isset()`) with 26 array
  offsets went from "does not finish" to 1.6 s.
* Probed the sibling accessory types: every other `getDefaultBaseType()` returns a
  non-union (`string`, `array`, `ObjectWithoutClassType`), so `HasOffsetValueType` is
  `HasOffsetType`'s only twin here - it is covered by the same fix and by its own test.
  `array_key_exists()` chains, `isset()` on property fetches (`HasPropertyType`) and `??`
  chains were measured and were never affected.
* Added `TypeCombinatorTest::testIntersectManyAccessoryTypesSharingAUnionBaseType` (both
  accessory types, asserting the unchanged result plus a wall-clock budget: 64 s before,
  4 ms after), `testIntersectRepeatedUnions`, and `tests/bench/data/bug-15061.php` with
  the reported reproducer.
@staabm
staabm force-pushed the create-pull-request/patch-gwwhj8w branch from 696a0d2 to d1aee39 Compare August 8, 2026 08:39
Comment thread src/Type/TypeCombinator.php Outdated
phpstan-bot and others added 2 commits August 8, 2026 09:02
…s it

tests/bench/data/bug-15061.php measures the same blowup end to end, so the
wall-clock budget in the unit test only adds a flaky duplicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deduplicate where the duplicate operands are built instead of dropping
repeated unions after the fact in doIntersect(): every accessory type
contributes its getDefaultBaseType(), and hasOffset()/hasOffsetValue() both
return `array|ArrayAccess`, so n of them handed intersect() n identical
two-member unions to distribute over each other - 2^n recursive calls.

Skipping a base type that an earlier accessory already contributed is sound
because intersection is idempotent, and it keeps the rest of doIntersect()
untouched. The reproducer from the issue goes from over three minutes
(timeout) to 2.1 s, against 4.3 s for the previous placement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update PR title and description to reflect the latest state of the PR

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Both checks are green and the tree is clean at 2cd33ec69. No code changes were needed for this review — it asks only for the PR title and description to catch up with the last two commits. gh pr edit is blocked on this repo (Pull requests are disabled for this repository), so here is the updated text for the posting step.


New title

Do not collect the same accessory default base type twice

New description

isset() with many array offsets made analysis time double with every extra offset — the reported snippet (26 optional keys of a @phpstan-type array shape checked in one isset()) never finished, while 13 offsets already took a minute.

Root cause

Every AccessoryType must be given a base type, which TypeCombinator::doIntersect() supplies with

return self::intersect(self::intersect(...$accessoryBaseTypes), ...$types);

where $accessoryBaseTypes holds one getDefaultBaseType() per accessory type. Each isset() subject narrows the array with a HasOffsetType, and HasOffsetType::getDefaultBaseType() returns the union array|ArrayAccess. So n offsets built a list of n identical two-member unions, and the A & (B | C) distribution multiplies operands out one union at a time — 2^n recursive intersect() calls before the duplicates were finally recognized at the leaves by the pairwise isSuperTypeOf() pass.

Change

src/Type/TypeCombinator.php — while collecting $accessoryBaseTypes, skip a base type that an earlier accessory already contributed (equals()). This is sound because intersection is idempotent, and it fixes the problem where the duplicate operands are built rather than cleaning them up afterwards, so the rest of doIntersect() is untouched.

The two accessory types that return a union from getDefaultBaseType()HasOffsetType and HasOffsetValueType, both array|ArrayAccess — are the ones that could blow up. The rest return string, array or ObjectWithoutClassType; their base-type intersection was already polynomial, and deduplicating them only shortens the operand list. On the PHP level the affected constructs are isset() chains and !empty() chains (empty() narrows through !isset()); array_key_exists() chains, isset($obj->x, $obj->y, …) (HasPropertyType) and ($a['x'] ?? '') !== '' chains were already linear and are unchanged.

The change is purely about how the answer is computed — the resulting types are identical before and after.

Measurements

Reproducer at -l 8, 26 offsets, cold cache:

wall clock
before > 300 s (timed out)
after 3.3 s

@staabm's full benchmark run shows no movement on any other data file, with the new bug-15061.php at ~261 ms.

Test

tests/bench/data/bug-15061.php — the reproducer from the issue, added to the benchmark suite the way other performance regressions in this repo are pinned. Per review feedback there is no unit-level timing test; the benchmark data file covers the same blowup end to end without a flaky wall-clock budget.

make tests OK (21235 tests, 96758 assertions, 97 skipped), make phpstan no errors.

Fixes phpstan/phpstan#15061


What changed versus the description currently on the PR: the fix now lives at the accessory base-type collection site instead of a removeDuplicateUnions() pass in doIntersect() (2cd33ec69, following your call-site suggestion), so the UnionType/TemplateUnionType/BenevolentUnionType caveats no longer apply and are dropped; the TypeCombinatorTest additions are gone (e896fe974, your earlier comment), leaving tests/bench/data/bug-15061.php as the only test change; and the timings are re-measured against the current code rather than the earlier placement.

@staabm staabm changed the title Drop repeated UnionType operands in TypeCombinator::intersect() before distributing them Faster HasOffsetType, HasOffsetValueType intersection Aug 8, 2026
@staabm
staabm merged commit 81e06a5 into phpstan:2.2.x Aug 8, 2026
761 checks passed
@staabm
staabm deleted the create-pull-request/patch-gwwhj8w branch August 8, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance regression when checking nullable array keys in 2.2.8

2 participants